JVM Back to the Basics - The Class Loader Subsystem

3 minute read

The Class Loader Subsystem

클래스 로더 서브시스템은 쉽게 말해, 클래스 로더 서브시스템은 타입을 찾고 로딩하는 역할을 한다. 좀 더 구체적으로 말해, 클래스에 대한 바이너리 데이터를 찾고 임포트하는 것 외에도 임포트된 클래스들의 correctness를 검증하고, 클래스 변수에 대한 메모리를 할당 및 초기화하고, symbolic reference를 resolve하는 것을 돕는다. 이러한 활동들은 다음과 같이 엄격한 순서로 진행된다.

  1. Loading: 타입에 대한 바이너리 데이터를 탐색하고 임포트 하는 것
  2. Linking
    1. Veritifaction
    2. Preparation
    3. Resolution (Optional)
  3. Initialization

Loading

Loading이란 클래스 파일들을 읽어서 그에 대응되는 바이너리 데이터를 method area에 저장하는 것을 의미한다.

각 클래스에 대해 JVM은 다음과 같은 정보를 method area에 저장한다:

  1. 로딩된 클래스, 인터페이스, 혹은 Enum의 fully qualified name
  2. 그 클래스의 부모 클래스의 fully qualified name
  3. .class 파일이 Class, Interface, Enum 중 어떤 것과 관련이 있는지
  4. 제어자 정보
  5. 변수 혹은 필드 정보
  6. 메소드 정보
  7. Constant pool 정보 등등

.class 파일을 로딩한 후, JVM은 바로 type Class의 객체(주어진 클래스에 대한 메타 데이터를 가지는 Class 타입의 객체)를 생성할 것이다. 클래스 레벨 바이너리 정보를 가지는 이 Class 객체를 Heap Memory에 저장한다.

jvm-class-loading

이 Class 객체를 통해 개발자들은 클래스 레벨 정보 (FQN, 메소드, 필드 정보 등)를 얻을 수 있다. 다음 예시를 보자.

import java.lang.reflect.Field;
import java.lang.reflect.Method;

class Student {
    private String firstName;
    private String lastName;
    private int rollNumber;

    public String getFirstName() {
        return firstName;
    }

    public void setFirstName(String firstName) {
        this.firstName = firstName;
    }

    public String getLastName() {
        return lastName;
    }
    
    public void setLastName(String lastName) {
        this.lastName = lastName;
    }
    
    public int getRollNumber() {
        return rollNumber;
    }
    
    public void setRollNumber(int rollNumber) {
        this.rollNumber = rollNumber;
    }
}

public class StudentInformation {

    public static void main(String[] args) {
        
        Student student = new Student();
        
        Class c = student.getClass();
        System.out.println(c.getName());

        Method[] m = c.getDeclaredMethods();
        for (int i = 0; i < m.length; i++) {
            System.out.println(m[i]);
        }

        Field[] f = c.getDeclaredFields();
        for (int i = 0; i < f.length; i++) {
            System.out.println(f[i]);
        }
    }
}

Output:

com.example.Student
public void com.example.Student.setFirstName(java.lang.String)
public void com.example.Student.setRollNumber(int)
public void com.example.Student.setLastName(java.lang.String)
public java.lang.String com.example.Student.getLastName()   
public java.lang.String com.example.Student.getFirstName()   
public int com.example.Student.getRollNumber()   
private java.lang.String com.example.Student.firstName   
private java.lang.String com.example.Student.lastName   
private int com.example.Student.rollNumber

NOTE: .class를 사용하면, 예를 들어 String.class or Student.class, 이는 주어진 클래스에 대한 메타 데이터를 가지고 있는 클래스 리터럴, 즉 java.lang.Class 객체를 참조한다. 이는 위 예시에서 사용한 getClass()와 같은 객체를 리턴한다. 다른점은 getClass()는 주어진 클래스의 인스턴스에 호출할 수 있고, .class는 인스턴스가 없어도 사용할 수 있다. 결국에 이로 인해 리턴되는 타입은 Class<?>인 것이다.

로딩된 각 .class 파일에 대해서는 단 하나의 Class 객체만 생성된다.

Linking

ClassLoder sub-system의 두 번째 활동은 Linking이다. Linking에는 세 가지 활동이 있다.

1) Verification Verification은 클래스의 바이너리 형태가 구조적으로 올바른지를 확인한다. 따라서 JVM은 .class file이 올바른 컴파일러로부터 생성되었는지, 올바른 포맷을 갖고 있는지를 확인한다. 내부적으로 바이트코드 검증기가 이를 수행한다. 그럼 만약 검증을 통과하지 못한다면? - Runtime Exception이 발생한다 (java.lang.Verify.Error).

2) Preparation Preparation 단계에서 JVM은 class-level static 변수에 대한 메모리를 할당하고 디폴트 값을 부여한다.

3) Resolution Resolution 단계에서는 로딩된 타입에 의해 사용되는 symbolic reference를 원래의 reference로 바꾸는 과정을 거친다. Symbolic reference에 대응하는 direct reference를 Method area를 탐색하여 찾아낸다. 예를 들어, 다음과 같은 코드 스니펫이 있다고 가정하자.

class Demo {
    public static void main(String... args) {
        String name = new String("John");
        Student s1 = new Student();
    }
}

ClassLoader sub-system은 위 코드를 읽으며 Demo.class, String.class, ‘Student.class, 그리고Object.class`(parent class)를 로딩할 것이다. 그리고 이 클래스들의 이름은 이 특정 클래스 “Demo”의 Constant Pool에 저장된다. Constant Pool은 .class 파일의 일부분으로써 해당 클래스의 코드를 실행하기 위해 필요한 constant들을 포함한다.

여기서 constant는 개발자에 의해 명시된 리터럴과 컴파일러에 의해 생성된 symbolic reference를 말한다. Symbolic reference는 다름이 아닌 코드에 있는 클래스, 메소드 및 필드를 가리키는 하나의 reference값이고, 이러한 reference값을 통해 JVM이 코드 부분 부분이 어떤 다른 클래스에 의존하는지를 찾아내는 Linking-Resolution 단계를 수행하는 것이다.

즉, 모든 symbolic reference는 런타임 constant pool에 있고 이는 method area에 있는 것이다 (사실 runtime constant pool의 구조는 implementation-specific하다). 그래서 이 Resolution 단계에서는 이러한 symbolic reference를 JVM에 의해 로딩된 실제 타입으로 변환하는 것이다. 즉, 프로그램 코드 상의 symbolic reference가 메모리 상의 실제 레퍼런스로 변환되는 것이다.

예를 들어, 다음과 같은 간단한 예시를 보자.

System.out.println("Hello World!");

javap -verbose를 실행하면 다음과 같은 아웃풋이 나온다.

0:   getstatic        #2; //Field java/lang/System.out:Ljava/io/PrintStream
3:   ldc              #3; //String Hello, World!
5:   invokevirtual    #4; //Method java/io/PrintStream.println:(Ljava/lang/String;)V

#n이 constant pool에 대한 reference이다. #2System.out 필드, #3Hello World! 스트링, 그리고 #4PrintStream.println(String) 메소드에 대한 symbolic reference이다.

Initialization

ClassLoader sub-system의 다음 단계는 Initialization이다.

이 단계에서 모든 static 변수들은 코드 상에 적힌 원래의 값들로 초기화되고, static 블록들은 위에서 아래로 실행된다.

출처

https://codezup.com/java-virtual-machine-jvm-architecture/

Leave a comment